Skip to content

Add SSH certificate authentication for targets, issued by Vault - #2397

Open
janisdombr wants to merge 47 commits into
warp-tech:mainfrom
janisdombr:feat/vault-ssh-certificate-auth
Open

Add SSH certificate authentication for targets, issued by Vault#2397
janisdombr wants to merge 47 commits into
warp-tech:mainfrom
janisdombr:feat/vault-ssh-certificate-auth

Conversation

@janisdombr

@janisdombr janisdombr commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Warpgate authenticates to an SSH target with a short-lived OpenSSH user certificate signed on demand by HashiCorp Vault, instead of a private key it stores. The ephemeral keypair is generated per connection and never persisted, so a compromise of the Warpgate host yields nothing a target would accept.

Targets trust the CA through TrustedUserCAKeys and need no authorized_keys. The certificate's key ID carries the Warpgate username and session UUID, so the target's own sshd log attributes a proxied session to a person rather than to the gateway.

VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and GCP. Each reads its credential from a file or a metadata service, never from the config: a static Vault password would merely relocate the long-lived secret this feature exists to remove. Full compatibility with OpenBao is supported.

Verified end to end against real infrastructure — AWS STS, a GCE instance, an Azure VM and a k3d cluster.

tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither Vault nor a cluster.

Discussion: #26

Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.

Description

...

AI Usage

Choose the level of AI involvement for this PR.

  • Fully vibe coded
  • AI-designed, AI-coded, manually checked
  • Human-designed, AI-coded
  • Human-designed, human-coded (includes AI autocompletions and boilerplate gen)

This is not to block AI contributions but rather to speed up PR review (saves time on trying to deduce the logic behind AI hallucinations).

@rumfellow

Copy link
Copy Markdown

Will be happy to see this merged since it's the only deployment blocker for us due to security concerns.

@theredspoon

Copy link
Copy Markdown

Went through this again against the current head (1578fc67), plus a step back from line-level review to look at the design itself.

Real progress since the last round: the AWS static-credential gap is now a genuine, well-designed fix, AwsError::StaticCredentialsDisallowed rejects when no STS session token is present, correctly distinguishing static IAM keys from any temporary/workload-identity credential. And the cached Vault token is now wrapped in zeroize::Zeroizing instead of the old Secret<String>, closing the memory-zeroization gap from last round.

Three things from the last round are still open, each with a concrete fix.

Vault issuer errors reaching the SSH client are still truncated to 256 characters rather than sanitized by content, so a policy or role name can survive that length. The fix is a client_message()-style method on VaultError that returns a generic, category-level message ("Vault denied the certificate signing request," "Vault is currently unavailable") to the SSH client, with the full error logged server-side instead, and this isn't Vault-specific, ConnectionError::Aws falls through the same catch-all in session.rs today, so it's worth fixing as a shared mechanism rather than a one-off for this arm.

stub_vault.py has no request-shape validation for the AWS, Azure, or GCP login paths, only Kubernetes and AppRole get checked, so the suite can't catch a malformed request on three of the five methods, worth adding the same presence checks those two already get.

Also, test_aws_signs_the_global_endpoint_by_default is currently broken, confirmed by actually running it: it supplies static credentials with no session token, so StaticCredentialsDisallowed correctly rejects before Warpgate ever reaches Vault, and the test's own assertion crashes with an empty-list error since Vault is never contacted. Fix is small: add AWS_SESSION_TOKEN to that test's env the same way the sibling test does.

The bigger thing: stepping back from individual lines, there's a structural question worth resolving before this merges. Under the current design, Vault can't distinguish one target/session from another, role, principals, and key_id are all values Warpgate's own code asserts in the signing request, not anything Vault independently verifies. Under the model this replaces, compromising Warpgate's stored credentials was bounded by whatever was actually stored for actually-configured targets. Under this one, a compromised Warpgate can request a cert for any role its Vault token is allowed to sign for, and role defaults to one shared value across every target unless each one is individually configured otherwise. Certs also aren't revocable today, no KRL, no rotation path in this diff. None of this shows up in a line-by-line read, because every line does what it says, it's a property of what the whole system ends up guaranteeing. Posted a concrete proposal for this as a follow-up comment.

One more thing worth knowing before this merges: #2185 also adds Vault integration (a different problem, relocating static secrets into KV rather than issuing certs, but it collides mechanically with this PR in several places, workspace crate registration, Services, ConnectionError, SSHTargetAuth), and its TLS/mount-configurability work already solves two gaps in this PR's own Vault client. Posted the details as a comment on that PR, but flagging it here too since it affects how and when this one should land.

This doesn't mean the direction is wrong. Ephemeral, non-stored credentials is the right fix for a real, long-standing gap, and the mechanics here are solid.

@theredspoon

Copy link
Copy Markdown

Opened #2400 with a concrete design for the authorization question from the review above, rather than posting the whole thing inline here.

Short version: the core piece there, identity-templated Vault roles plus per-session scoped child tokens, so Vault verifies the principal instead of trusting what Warpgate asserts, belongs in this PR before merge, not a fast-follow. Without it, this design can plausibly have a worse worst-case blast radius than what it replaces (fleet-wide, non-revocable access versus today's bounded-to-stored-credentials), so it's not a good candidate for shipping as a documented limitation. The remaining hardening in the issue (full IdP-verified non-repudiation, host-binding, revocation) is genuinely separable follow-up work once that baseline is in.

@janisdombr
janisdombr force-pushed the feat/vault-ssh-certificate-auth branch 2 times, most recently from c00a253 to bc0fb04 Compare August 10, 2026 15:19
@janisdombr

Copy link
Copy Markdown
Contributor Author

@theredspoon Thank you for the follow-up review!
I've addressed the three technical points in commit bc0fb04c:

  1. AWS Test Fix: Added AWS_SESSION_TOKEN to test_aws_signs_the_global_endpoint_by_default's test environment so it tests global endpoint signing without triggering StaticCredentialsDisallowed.
  2. SSH Terminal Error Sanitization: Added client_message() to VaultError, AwsError, and ConnectionError. Full error bodies and Vault topology details are now strictly logged server-side (tracing::error!), while SSH client terminals receive safe, generic messages ("Target connection failed: Vault denied the certificate signing request").
  3. Payload Validation in Stub: Added request-shape presence checks for AWS (iam_http_request_method, iam_request_url, iam_request_body, iam_request_headers), Azure (jwt, subscription_id), and GCP (jwt) login paths in stub_vault.py.

@theredspoon

Copy link
Copy Markdown

Went through the current head (bc0fb04c) again. Two new issues that weren't caught in earlier rounds, plus a few smaller items.

X-Vault-Token can leak on redirect

warpgate-vault/src/client.rs:94 builds the reqwest::Client with no redirect policy:

let http = reqwest::Client::builder().timeout(config.timeout).build()?;

That leaves reqwest's default policy in place, which follows redirects and only strips Authorization/cookies/proxy-auth headers on a cross-origin hop. It has no concept of X-Vault-Token as sensitive, so a 307/308 from the sign or unwrap endpoint (compromised Vault, misconfigured proxy, or MITM) replays the token to a different host, or moves an HTTPS request to HTTP. Please resolve with redirect::Policy::none() on this client and a regression test asserting the token is never forwarded cross-origin.

Unbounded buffering + panic in error-body truncation

warpgate-vault/src/client.rs:354-367:

let body = response.text().await.unwrap_or_default();
let max_len = 256;
let body = if body.len() > max_len {
    format!("{}... (truncated)", &body[..max_len])
} else {
    body
};

response.text() buffers the entire body before the length check runs, so a hostile or misbehaving endpoint can force unbounded allocation. Separately, &body[..256] panics whenever byte 256 falls inside a multi-byte UTF-8 character (255 ASCII bytes followed by é, for example). This is reachable from anything answering as the configured Vault address. Please resolve by streaming a bounded prefix and truncating at a char boundary, or truncating the already-bounded raw bytes lossily.

Smaller items

  • read_credential() (client.rs:338-348) zeroizes the buffer it reads into, but returns a fresh, non-zeroized trimmed copy that then gets copied again into the JSON login body. The cached Vault token is correctly zeroized; the K8s JWT / AppRole secret ID / wrapping token read from disk are not. Please resolve end-to-end: zeroize trimmed and the JSON copy too, not just the initial read buffer.
  • tests/stub_vault.py:61-82: the Azure login check only requires jwt + subscription_id, not resource_group_name/vm_name/vmss_name; the AWS check only verifies four fields are truthy without decoding or checking the request is actually GetCallerIdentity. Some of this is covered by assertions elsewhere in the integration tests, but the stub itself validates less than its shape suggests.
  • Azure/GCP metadata_address is administrator-configurable and blindly GETed (warpgate-common/src/config/mod.rs:458 / metadata::gcp_identity_token), so a compromised config file can trigger SSRF from the Warpgate host. This doesn't cross a privilege boundary on its own since editing warpgate.yaml already requires host access, but it should get a line in the docs.

@janisdombr

Copy link
Copy Markdown
Contributor Author

@theredspoon both fixed, thanks.

Redirects are refused outright now, which covers the metadata calls too. The error body is read chunk-wise with a 256-byte cap and truncated lossily, so a split character can't panic it. Chasing that one, I found the success path had
no bound at all — a 200 MB signed_key took a live gateway from 74 MB to 680 MB RSS, per session in flight.

Zeroization is end-to-end now: the login body goes through typed structs instead of a serde_json::Value, so there's no stray copy of the JWT or secret ID left around. The one remaining is reqwest's own send buffer, which I
commented rather than pretended about.

The stub validators actually validate now — decoded AWS payload, full Azure coordinates, JWT shape, GCP audience — and have tests of their own. You were right that they were asserting nothing.

A pass over the rest turned up a few more: lease_duration: 0 was read as expired, so every request re-logged in; nothing was checked about the returned certificate, so a host cert or one over a key we don't hold both went on the
wire; a comma in the target username widened valid_principals. Also added an optional certificate_ttl.

One I'd like your view on: a role with default_critical_options can put a force-command in the cert, and the target runs that instead of what the user typed. I made it warn rather than refuse — a restricted role might set one
deliberately, and a hostile Vault has target access anyway. If you think the stealth is the point, I'll make it refuse behind an opt-in.

425fb05. metadata_address is in the docs now. OpenBao offer still very
welcome.

@janisdombr
janisdombr force-pushed the feat/vault-ssh-certificate-auth branch 2 times, most recently from 4d8e294 to dd06172 Compare August 10, 2026 22:46
@theredspoon

Copy link
Copy Markdown

Confirmed everything in dd061726 — redirect refusal, the chunked/lossy truncation fix, the response-size cap, lease_duration: 0 handling, the certificate type/key checks, and the comma-in-principal validation all hold as described. Ran the focused test suite too, all passing.

On critical_options: refuse by default, gated behind an explicit per-target opt-in.

The "hostile Vault already has target access anyway" framing undersells this. force-command isn't a subset of what a fully compromised Vault could already do directly: it runs under the connecting user's own principal and key_id, so it launders attribution in the target's own sshd log in a way a direct malicious connection never would. There's also a lower-privilege path than full Vault compromise: Vault's ACLs separate write access to ssh/roles/* from sign/* and from actual network reachability to targets. Someone with only role-config write, no signing rights and no path to the target, could plant a default_critical_options on a role and wait for a legitimate session to carry it through Warpgate. That's a materially lower bar than the #2400 threat model, and Warpgate is the only place that check can land.

The realistic case day to day is more mundane than either: a legitimate, uncompromised Vault, an operator who copies or templates a role with default_critical_options set, and a connecting user who gets no signal at all beyond a warn-level server log they're not watching.

Suggest: default-reject any critical option. Per-target opt-in as a named allow-list of expected option keys, not a bare boolean, and for force-command specifically, pin the exact expected command where practical rather than accepting any value. A rejection should reach the user the same way the certificate-mismatch errors do now, not just the log.

Two more, from this round:

lease_duration can panic Warpgate. warpgate-vault/src/client.rs:348-350:

expires_at: (auth.lease_duration > 0).then(|| {
    Instant::now() + Duration::from_secs(auth.lease_duration).saturating_sub(TOKEN_EXPIRY_MARGIN)
}),

lease_duration is an untrusted u64 straight from Vault's response, no upper bound. Instant + Duration panics on overflow. A misbehaving or compromised Vault returning an oversized lease crashes the process, on every login path. Please resolve with checked_add, rejecting an unrepresentable lease as an API error rather than crashing on it.

IPv6 loopback is misclassified as insecure. validate_address (client.rs:38) checks host == "::1", but url::Url::host_str() returns "[::1]" with brackets for an IPv6 host, confirmed by compiling and checking directly. A genuine loopback IPv6 Vault address (http://[::1]:8200) gets rejected as insecure the same as a real remote HTTP address would. Low severity, but a real bug for anyone running Vault dev-mode over IPv6 loopback.

One more, lower priority: the AWS path is the one exception to end-to-end zeroization. StsIdentityRequest.headers (an ordinary HashMap<String, String> carrying the SigV4 signature and session token) and the base64-encoded strings built from it in aws_login_body() are never wrapped in Zeroizing, unlike the Kubernetes/AppRole/Azure/GCP/token paths. Worth closing for consistency, not urgent given these are temporary credentials rather than static keys.

@theredspoon

theredspoon commented Aug 11, 2026

Copy link
Copy Markdown

Ran a wider architectural sweep across the codebase, not just this PR's diff, then went back and verified every proposed fix against the real code and this PR's own existing patterns.

Certificate minting via the host-key-check admin endpoint

warpgate-admin/src/api/ssh_connection_test.rs's connection-test handler returns to its own caller as soon as HostKeyReceived fires, but the underlying RemoteClient task doesn't get that signal and falls through to authenticate_session regardless (warpgate-protocol-ssh/src/client/mod.rs:824). For a Certificate-auth target with an already-trusted host key, that mints a real cert and opens a real authenticated session in the background. Measured directly with a throwaway integration test: the session holds for a minimum of 310.6 seconds (the 5-minute inactivity timeout plus a 10s pad), indefinitely if ssh.keepalive_interval is configured, and the task itself leaks on every press, since create() discards its JoinHandle (client/mod.rs:361) and the read loop never exits on this path, so Drop for RemoteClient never runs. The throwaway session UUID is never registered via register_session, so certificate_key_id() falls back to warpgate:<random-uuid> with no username. (First press against an untrusted host key self-cleans in ~1ms, since HostKeyUnknown fires after the admin loop already broke on HostKeyReceived — it's every press after the key is trusted that holds.)

The fix needs to be a deterministic signal, not a race. abort_rx/abort_tx exist, and having the task treat a dropped abort_tx as cancellation is safe for real sessions (ServerSession's Drop impl explicitly sends the abort signal first, so there's no legitimate case where a real session drops it while still wanting the connection) — but tokio::select! is unbiased, and authenticate_session is called inside the same fut_connect arm that resolves concurrently with HostKeyReceived firing mid-KEX, so a drop signal alone still loses the race roughly half the time. Please resolve with an explicit intent passed from ssh_connection_test.rs into the connect command (a stop_after_host_key flag or a dedicated command variant) that makes wait_for_connection return before authenticate_session deterministically, not conditionally on a race. The dropped-abort_tx handling is still worth adding as defense in depth alongside it.

Separately: create() should hold and abort its JoinHandle instead of discarding it, but scoped to the admin-side caller specifically. Applying it to the shared RemoteClientHandles type would hard-abort real sessions instead of letting ServerSession::drop's existing graceful disconnect() run, losing a clean Disconnect::ByApplication on the target side.

Vault config doesn't hot-reload

services.rs:80-86 builds VaultClient once from the startup config snapshot into vault: Option<Arc<VaultClient>>. Every other config section hot-reloads through the watch::Sender in config.rs's reload path; Vault was never wired in. VaultConfig/VaultAuth are missing PartialEq/Eq (all fields are String/PathBuf/Option/Duration, so deriving both is straightforward, and ListenerParams already does the same for the same reason). The swappable-cell mechanism this needs already exists in this codebase: warpgate-core/src/rate_limiting/swappable_cell.rs's SwappableLimiterCell, built on watch::Sender<Option<T>>, documented as "a cell containing a reference which can be swapped out wholesale." One ordering constraint: Services::new runs before watch_config is called (run.rs:83 vs :122), so the rebuild loop can't live inside Services::new — it needs to be spawned from run.rs after watch_config, the same place the existing ListenerSupervisor gets spawned, following that same diff-and-rebuild shape (listener_supervisor.rs:132-171), keeping the old client on a validation failure the same way it keeps the current listener. Please resolve, editing or removing the vault: section currently has no effect until a restart.

Cloud metadata tokens can transit an ambient proxy

VaultClient's single reqwest::Client (client.rs:202-205, comment at :200 says "the same client fetches cloud metadata") is used both for real Vault calls and for Azure/GCP metadata-token fetches, with no explicit proxy policy, so reqwest's default of honoring HTTP_PROXY/HTTPS_PROXY/NO_PROXY applies. Both metadata defaults are plain HTTP (169.254.169.254, metadata.google.internal); GCP's is a hostname, which a typical IP-based NO_PROXY list won't match. Please resolve with a second metadata_http: reqwest::Client built with .no_proxy(), used only for the two metadata call sites, leaving the main Vault-address client's ambient proxy support untouched. AWS doesn't go through this client.

key_id enforcement and error messages

Checked against Vault's actual server source (calculateKeyID, builtin/logical/ssh/path_issue_sign.go, unchanged since v1.4.0): a role with allow_user_key_ids=false only falls back to the token's display name when the caller sends no key_id at all. When a non-empty key_id is sent and the role doesn't allow it, Vault returns an error and issues nothing. Warpgate always sends a non-empty key_id (client/mod.rs:841), so a misconfigured role already fails closed today, there's no silent wrong-attribution here, and a client-side key_id check would be unreachable.

Three real things remain from that investigation:

  • The error Warpgate surfaces for this case is the generic "Vault denied the certificate signing request." Please resolve by mapping the specific setting key_id is not allowed by role response body to a message that names the fix (allow_user_key_ids=true on the role).
  • certificate_mismatch() doesn't check valid_principals. Vault returns the requested principal set verbatim (trimmed, deduped, sorted) or hard-errors, never silently widens it, independent of the key_id question above. Please resolve by adding that check, using certificate.valid_principals().iter().any(|p| p == principal) rather than exact equality, since Vault sorts the returned set.
  • Found separately while checking this: a Warpgate-side refusal (the existing cert-type/pubkey mismatch checks, or the principals check above) currently surfaces as ConnectionError::Authentication, whose client message is "SSH target rejected Warpgate's authentication request" — inaccurate, since the target never received anything in that case. Please resolve with its own error variant.

Response-wrapped AppRole secret IDs need the unwrapped value cached

login_body() re-reads and re-unwraps the same file on every login (client.rs:375-379), but wrapping tokens are single-use, so every login after the first fails, surfacing only as the same generic "Vault denied the certificate signing request."

Response wrapping protects one-time delivery of the secret ID, it doesn't force single-use of the secret ID itself. secret_id_num_uses/secret_id_ttl separately govern how many times the unwrapped secret ID can authenticate, and HashiCorp's own AppRole guidance for long-running services is to unwrap once at initialization and reuse the result for subsequent logins until it expires. That matches this PR's own README, which already describes the intent as "the secret ID is read fresh on every login, so it can be rotated underneath a running Warpgate", rotation as something available on demand, not required before every login.

Please resolve by caching the unwrapped secret ID, keyed on the raw file content, reusing it while the file is unchanged and only re-unwrapping when the content actually changes (an operator writing a fresh wrapping token). Keep a distinct error for the real failure case, an unwrap attempt (first use, or after a detected change) that fails because the token is stale or already consumed: VaultError::SecretIdUnwrap { path }, naming the file and stating that the provisioning process needs to write a fresh wrapping token, e.g. via vault write -f -wrap-ttl=<ttl> auth/approle/role/<role>/secret-id.

Lower priority

  • A target with an empty username substitutes the connecting Warpgate user's own username as valid_principals. Not a bypass, Vault's allowed_users still rejects anything out of policy, but please resolve by documenting this mode in the README alongside the existing allowed_users guidance.
  • default_extensions on a returned certificate are neither checked nor logged, while critical_options now are. Please resolve by logging default_extensions the same way, for the same operator-visibility reason critical_options was.

@janisdombr
janisdombr force-pushed the feat/vault-ssh-certificate-auth branch from 409e2e5 to 4b825c1 Compare August 11, 2026 06:48
@janisdombr

Copy link
Copy Markdown
Contributor Author

Both rounds are in commit 409e2e5.

@theredspoon
Before the details: your analysis and recommendations are worth more than everything I put in this PR. The code and tests were the easy part; what you did was find the things that made them wrong. Without your reviews this would
have shipped as one continuous hole with a feature description on top, not as an improvement. Twelve findings across the rounds, and the two most serious - the host-key check minting certificates, and the AppRole path that broke on
every login after the first are ones no amount of testing my own diff would have surfaced, because I was testing the diff and you were reviewing the system it landed in.

On critical options you changed my mind. "A hostile Vault already has target access" conflated two different capabilities: force-command isn't extra access, it's laundered attribution, and the target's own log is the thing this feature exists to make trustworthy. The role-write-without-sign path settles it. So: default-reject, per-target allow-list of names with optional pinned values, and the refusal reaches the connecting user rather than a log nobody watches.

Everything else landed as you described it checked_add on the lease, url::Host for IPv6, Zeroizing on the AWS path, the allow_user_key_ids message, valid_principals checked with any not equality, the unwrapped secret ID cached against file content, a VaultCell rebuilt from run.rs beside the listener supervisors, and a separate no_proxy client for metadata.

Two places I'm weaker than I'd like, said plainly:

The host-key check I took the explicit-intent route, a dedicated RCCommand::CheckHostKey that returns before authenticate_session, final hop only. What I can demonstrate is the leak: revert it and my test fails on connections still open after the request returned. What I could not reproduce is the certificate actually being minted the leaked task stalls before signing in my setup, over a 5s window. That assertion is a guard, not evidence; your 310.6s measurement is the real data point. If you can share how you drove it to sign I'll make it deterministic.

The JoinHandle I didn't thread one through. CheckHostKey ends the task, and the admin caller sends an explicit abort afterwards, scoped so ServerSession's graceful disconnect stays untouched. Two mechanisms rather than the third you
suggested; say the word and I'll add it.

Tests are 15 Rust unit and 57 integration, up from 12 and 48. Each new one was verified by breaking the code it defends including one that didn't fail on the first attempt, the valid_principals case, which rejects that certificate too. Rewritten to assert who did the refusing.

Warpgate authenticates to an SSH target with a short-lived OpenSSH user
certificate signed on demand by HashiCorp Vault, instead of a private key it
stores. The ephemeral keypair is generated per connection and never persisted,
so a compromise of the Warpgate host yields nothing a target would accept.

Targets trust the CA through TrustedUserCAKeys and need no authorized_keys.
The certificate's key ID carries the Warpgate username and session UUID, so the
target's own sshd log attributes a proxied session to a person rather than to
the gateway.

VaultAuth offers workload identity only — kubernetes, AppRole, AWS, Azure and
GCP. Each reads its credential from a file or a metadata service, never from
the config: a static Vault password would merely relocate the long-lived secret
this feature exists to remove. Full compatibility with OpenBao is supported.

Verified end to end against real infrastructure — AWS STS, a GCE instance, an
Azure VM and a k3d cluster.

tests/test_ssh_target_cert_auth.py runs against a stub issuer and needs neither
Vault nor a cluster.

Discussion: warp-tech#26

Special thanks to @theredspoon for the detailed test, OpenBao evaluation, and security recommendations.
- The admin host-key check ran on into authenticating to the target. On a
  certificate target that minted a real certificate and opened a real session
  nobody was attached to, held until the inactivity timeout, with a key ID
  naming no user. Now a dedicated RCCommand::CheckHostKey stops before
  authentication, on the final hop only so jump hosts still authenticate.

- A certificate could arrive carrying critical options nobody asked for. A
  force-command there replaces what the user typed while keeping their own
  principal and key ID on the session, so the target's log attributes it to
  them. Write access to a Vault role is a lower bar than the right to sign with
  it, so this is the only place it can be caught. Refused by default; a target
  may name the options it expects and pin their values.

- Nothing checked that the certificate named the account being reached.
  valid_principals is now verified against the target's username.

- A response-wrapped AppRole secret ID was re-unwrapped on every login. A
  wrapping token is single-use, so every login after the first failed, as a
  generic denial. The unwrapped secret ID is now cached against the file
  content, and a genuine unwrap failure names the file and the fix.

- lease_duration from Vault fed an unchecked Instant addition, so an oversized
  lease crashed the process on the login path. Now rejected as a bad response.

- Cloud metadata tokens went through the same client as Vault, which honours
  HTTP_PROXY by default; GCE's hostname defeats a typical IP-based NO_PROXY.
  Metadata now uses a client built with no_proxy().

- The AWS login path was the one place credentials were not zeroized.

- An IPv6 loopback Vault address was classified as a remote plaintext endpoint,
  because host_str renders it with brackets.

- Editing the vault: section had no effect until a restart, alone among config
  sections. A VaultCell on a watch channel is rebuilt from run.rs; a
  configuration that fails to build keeps the working client.

- A certificate Warpgate itself refused reported "SSH target rejected
  Warpgate's authentication request", naming the wrong party. It has its own
  error now, and the reason reaches the connecting user.

- A role that forbids key IDs now produces a message naming allow_user_key_ids.

Tests: 15 Rust unit and 57 integration, up from 12 and 48; each new one
verified by breaking the code it defends. The stub models single-use wrapping
tokens, without which the AppRole defect was invisible.

Found by @theredspoon's review, which is worth more than the code it corrects.
@janisdombr
janisdombr force-pushed the feat/vault-ssh-certificate-auth branch from c5dea27 to 58f831a Compare August 11, 2026 11:54
The stub in tests/ is fast and can be made to misbehave, but it only knows what
we told it — and two of the defects found in review were invisible for exactly
as long as it was the only witness. tests/vault_server.py runs the suite against
a real HashiCorp Vault and a real OpenBao, reading requests back out of the
server's own audit device, so the payload under assertion is the one the server
received. Every behaviour the stub models is now pinned against both.

Three defects came out of it:

- Every login left a copy of the credential in freed memory. login_payload used
  serde_json::to_string, whose String grows as it is written and frees each
  smaller buffer without wiping it; Zeroizing only ever wipes the buffer that
  survives to the end. Size decides whether it shows: measured with a 4 KiB
  credential, which is what a Kubernetes service account token or a signed AWS
  header set actually is. Now serialized into a buffer reserved up front.

- The certificate's key ID was never checked against the one requested. A
  certificate carrying a 64 KiB key ID authenticated normally. The target's sshd
  logs that field verbatim, and "the target's own log names the person" is the
  claim this path exists to deliver, so an issuer returning a different one
  breaks attribution silently.

- The reason an authentication failed never reached the person connecting.
  ConnectionError::Authentication carried no detail; the reason went to the
  server log and the user got a fixed string. For a certificate refused because
  it is outside its validity window — the documented clock-skew hazard — that
  sends whoever is debugging it to check credentials that are fine. The variant
  now carries its reason and the certificate arm names the window.

Also documented: OpenBao refuses to enable an audit device over the API, and its
config stanza needs type, path and an options block — a top-level file_path is
accepted with a warning and then ignored, which looks exactly like a working
audit device that writes nothing.

Tests: 16 contract tests across Vault and OpenBao (five versions under
WARPGATE_VAULT_MATRIX=full), 8 for certificates a real issuer would never emit,
6 property tests over the validators, and 3 that watch the allocator to check
the zeroization claim rather than trusting it.
@janisdombr
janisdombr force-pushed the feat/vault-ssh-certificate-auth branch from 58f831a to d818090 Compare August 11, 2026 12:03
@janisdombr

Copy link
Copy Markdown
Contributor Author

Pushed d818090, rebased onto current main.

This round came from building the test infrastructure rather than from reading the diff again. tests/vault_server.py runs the suite against a real Vault and a real OpenBao, reading requests back out of the server's own audit device, so
assertions are on what the server received rather than on what our stub chose to remember. Three defects fell out:

  • Every login left a copy of the credential in freed memory. serde_json::to_string grows its String as it writes and frees each smaller buffer unwiped; Zeroizing only wipes the one that survives. Only shows at realistic sizes measured with a 4 KiB credential, which is what a K8s service account token actually is.
  • The certificate's key ID was never checked against the one requested. A 64 KiB key ID authenticated normally, which quietly breaks the attribution this whole path exists to provide.
  • The reason an authentication failed never reached the user — it went to the server log only. For a certificate outside its validity window, the documented clock-skew case, that sends someone to debug credentials that are fine.

Also OpenBao refuses to enable an audit device over the API, and its config stanza needs type, path and an options block — a top-level file_path is accepted with a warning and then ignored. Documented, since the issuance record on the Vault side is half the point.

Two CI gates are red and neither is from this branch:

  • biome fails on AuthPolicyEditor.svelte, which came in with 55af452 and is byte-identical here.
  • cargo-deny fails on RUSTSEC-2026-0253 (lru via ratatui), published after main's last green run. deny.toml already carries RUSTSEC-2026-0002 for the same crate with "no update available".

I left both alone rather than touch unrelated files in a security PR.

Three defects, found by reading other projects' advisories and by pointing two
tools at this code that had not been used on it before.

- A certificate naming more than the target account was accepted. The check
  asked whether the requested principal was among those returned; Vault returns
  the requested set verbatim or refuses, so anything extra means the answer did
  not come from this request. Each extra name is another account the target will
  accept the certificate for, chosen by whoever answered rather than by the
  operator, and under AuthorizedPrincipalsFile it need not resemble a username.
  Now required to be exactly the account asked for.

  This came from CVE-2024-7594, where an empty valid_principals yielded a
  certificate good for any user on the host, and CVE-2026-35414, where a comma
  inside a principal splits one name into two for one of sshd's checks and not
  the other. The second is also why the rule is "exactly one name" rather than
  "contains": it notes the attack works when the CA does not reject commas in
  what it is asked to sign, which is the check Warpgate already makes on the
  request side.

- A certificate could write escape sequences to the connecting user's terminal.
  The refusal message quotes the critical option's name straight out of the
  certificate and is printed to the PTY, so a name containing \x1b[2J cleared
  their screen rather than appearing in the text. Certificate-derived strings
  are now quoted with {:?}.

- The outbound SSH handshake had no bound of its own. A target that completes
  the TCP connection, sends a valid identification string and then goes silent
  held the gateway's task, socket and session slot until the *inbound* session's
  inactivity timeout fired — measured at 55s with that timeout set to 45s. That
  setting governs how long an idle interactive session may live and is
  legitimately raised to hours, every one of which extended this hold to match.
  Bounded now by a dedicated 30s deadline, with an error naming the stage so an
  operator is not sent to look at credentials.

tests/hostile_ssh_server.py is new: six ways of being a bad SSH server, none of
which needs Docker. The rest of the suite treats the target as honest, which is
the one trust boundary nothing here had pushed on — and russh, which Warpgate is
the client half of, has published pre-authentication panics reachable from the
peer. Five of the six modes were survived without change.

cargo mutants found the fourth problem, in the tests rather than the code: it
replaced the error-body reader with one returning an empty string and everything
still passed, because the assertions were all upper bounds. Ten mutants survived
in that one function. The truncation marker is now pinned from both sides.
@janisdombr

Copy link
Copy Markdown
Contributor Author

Pushed 6bd00e1. Three more defects, found by reading other projects' advisories and by pointing two tools at this code that had not been used on it before.

A certificate naming more than the target account was accepted. The check asked whether the requested principal was among those returned. Vault returns the requested set verbatim or refuses, so anything extra means the answer did not come from this request and each extra name is another account the target will accept the certificate for, chosen by whoever answered rather than by the operator. Under AuthorizedPrincipalsFile it need not resemble a username at all. Now required to be exactly the account asked for.

This came out of two advisories rather than out of the diff: CVE-2024-7594, where an empty valid_principals yielded a certificate good for any user on the host, and CVE-2026-35414, where a comma inside a principal splits one name into two for one of sshd's checks and not the other. The second is also why the rule is "exactly one name" rather than "contains" it notes the attack works when the CA does not reject commas in what it is asked to sign, which is the check on the request side that was already there.

A certificate could write escape sequences to the connecting user's terminal. The refusal message quotes the critical option's name straight out of the certificate and is printed to the PTY, so a name containing \x1b[2J cleared their screen rather than appearing in the text. Certificate-derived strings are now quoted with {:?}.

The outbound SSH handshake had no bound of its own. A target that completes the TCP connection, sends a valid identification string and then goes silent held the gateway's task, socket and session slot until the inbound session's inactivity timeout fired measured at 55s with that timeout set to 45s. That setting governs how long an idle interactive session may live and is legitimately raised to hours, every one of which extended this hold to match. Bounded now by a dedicated 30s deadline, with an error that names the stage.

tests/hostile_ssh_server.py is new and needs no Docker: six ways of being a bad SSH server. The rest of the suite treats the target as honest, which is the one trust boundary nothing here had pushed on and russh, which Warpgate is the client half of, has published pre-authentication panics reachable from the peer. Five of the six modes were survived without change; russh bounds the identification string itself, which covers two of them.

cargo mutants found the fourth problem, in the tests rather than the code: it replaced the error-body reader with one returning an empty string and everything still passed, because the assertions were all upper bounds. Ten mutants survived in that one function.

Checked and clean, for the record: russh 0.62.6 is current against all fourteen of its advisories, and allow_insecure_algos keeps the strict-KEX extensions, so the Terrapin mitigation is not lost in the mode meant for older devices.

CI is still red on biome and cargo-deny, and neither is from this branch the same two are red on #2409 and #2410. AuthorizedPrincipalsFile.svelte came in with 55af452 and is byte-identical here; RUSTSEC-2026-0253 (lru via ratatui) was published after main's last green run, and deny.toml already carries RUSTSEC-2026-0002 for the same crate.

@theredspoon

theredspoon commented Aug 11, 2026

Copy link
Copy Markdown

Ran a final-gate pass with three independent reviewers plus direct verification against real sshd servers, since this round changed enough surface (the real-Vault/real-OpenBao test harness, the critical_options allow-list logic, the CheckHostKey command) to be worth a genuinely fresh look rather than re-confirming what's already fixed. Everything from the last round not mentioned below has been confirmed separately. Two real, previously-unflagged issues, plus a cluster of smaller ones.

Host-key check returns the wrong key for any target behind a jump host

Already independently reported and being fixed: issue #2412 and its open fix, PR #2413 (resolve_ssh_chain tags each hop with its target_id, and the admin endpoint now waits for the specific hop being asked about rather than breaking on the first HostKeyReceived it sees). No need to duplicate that here.

What #2413 doesn't cover, since it's written against main and this PR's stop_after_host_key/CheckHostKey gating doesn't exist there yet: whether an intermediate hop's own authentication is actually prevented when only that hop's key is being checked. Right now stop_after_host_key is gated on is_last/hop_count, not on which target the caller asked about, so once #2413 lands and this rebases onto it, the same target_id tagging needs to also drive the stop_after_host_key decision, not just which key gets returned. Otherwise a caller checking an intermediate hop's key specifically (which #2413 makes possible to do correctly) still doesn't stop that hop from authenticating.

Related to that: no certificate gets minted for the jump host today, but that's not a construction guarantee the way it is for the final hop, it's the admin endpoint's abort winning a race against the SSH handshake, the same category of fragility CheckHostKey exists to eliminate. Confirmed empirically (40 consecutive presses, both host-key-known and unknown, 0 Vault sign requests, 0 userauth attempts logged), so not currently exploitable, but nothing structurally prevents it and there's zero test coverage for any chain longer than one hop.

Pinned critical options are only checked when the certificate actually carries them

certificate_mismatch()'s critical-options loop (client/mod.rs:167-185) iterates over options present in the returned certificate and checks each is allowed, correctly rejecting anything unexpected. It never checks the other direction: that a target's configured, pinned options are actually present. A target configured with a pinned force-command accepts a certificate carrying no force-command at all, no restriction, full shell. This is exactly the threat model already established for this feature, someone with Vault role-write but not sign-rights, just approached from the other side: instead of adding an unexpected option, they remove an expected one, and nothing here catches it. Please resolve by checking that every entry in the target's allowed_options with no value pin, or a specific value pin, is actually present in the returned certificate, not just that nothing extra showed up.

Smaller items, roughly by severity

  • warpgate-vault/src/metadata.rs's Azure (.json(), lines 40-52 and 54-66) and GCP (.text(), lines 78-92) calls buffer the full response with no size cap and no zeroizing of the intermediate buffer, the exact defect class this round's own read_json fix (client.rs, MAX_RESPONSE_BODY) closed for the main Vault client, just not mirrored here. Reachable on every Azure/GCP relogin.
  • LOGIN_PAYLOAD_CAPACITY (client.rs:118, 32KiB) is a pre-allocation hint, not a bound. read_credential doesn't cap what it reads from token_path/secret_id_path. A credential file larger than 32KiB reintroduces the grow-and-copy leak this round's zeroization fix was built to close, silently.
  • certificate_mismatch()'s valid_principals check verifies the requested principal is present, not that nothing else is. Already fixed in 6bd00e187, pushed while this review was in progress: now requires an exact single match rather than containment, stronger than what this would have asked for, backed by two real CVEs (CVE-2024-7594, CVE-2026-35414) that make this a better-documented severity than "low, needs a fully rogue Vault."
  • No check anywhere on the certificate's own validity window. tests/test_vault_hostile_certs.py:112 references SECURITY_TESTING.md to explain why this is accepted risk; that file doesn't exist in the repo. A misconfigured or compromised Vault role can hand back a certificate valid for years, unnoticed, bounded only by the role's own max_ttl, undocumented in the README's certificate_ttl section. Worth noting: Add Ssh cert auth for targets #1847, the alternative self-hosted-CA implementation of this same feature, defaults its own issued certificates to a 1-minute window, so short validity is already the project's own expectation, just not enforced on the receiving end here.
  • CI can go green with neither real issuer actually tested: a Docker image pull failure calls pytest.skip() (tests/vault_server.py:77) rather than failing the run, and the OpenBao image is pinned to mutable latest. This is a new pattern for this repo specifically, not an existing convention: nothing else in the test suite skips on infrastructure failure, .github/workflows/test.yml builds every other test image in an explicit step that fails the job outright on error. Given the harness is meant to be the contract gate, worth making pull failure a hard failure and pinning both images to a digest.
  • The AWS SigV4 headers are copied unwiped at least three more times beyond the serde_json::to_string line already flagged, warpgate-aws/src/sts_identity.rs's Credentials, the Identity it's moved into, and the http::Request header map apply_to_request_http1x writes the session token into. Worth scoping that fix to the whole AWS path rather than the one call site.
  • Two contract tests don't test what they claim: tests/test_vault_contract.py's key-ID test supplies a truncated/malformed public key that both real Vault and OpenBao reject at parse time, before allow_user_key_ids policy is ever consulted, so it passes without exercising the thing it's named for. Separately, server.signs (tests/vault_server.py:282) reads only the audit device's request entries, so the test asserting the certificate carries the right principals re-checks what Warpgate sent, not what either real server actually returned.
  • Sub-second certificate_ttl values fail only at connect time (Duration::as_secs() truncates to "0s", both real issuers reject it), not at config load. Worth validating at config time instead.
  • Minor UI bug: clearing a pinned critical-option value in the admin UI (Options.svelte) writes back an empty string rather than clearing the pin, so an operator who types a value and deletes it gets an exact-match-empty pin instead of "any value" as the placeholder implies. Fails closed, but the UI says the opposite of what happens.
  • warpgate-vault/tests/zeroization.rs:123-144 has orphaned, truncated doc comments describing tests that were deleted, and login_payload is private, so the suite's own safety-net tests reimplement the safe pattern inline rather than exercising the real function, reverting the actual fix would leave every assertion in that file green.
  • .github/workflows/docker.yml's IMAGE_NAME change to ${{ github.repository }} looks like unrelated fork scaffolding inside a security PR, worth dropping from the branch.

One more, separate from the above: the terminal-escape-sequence fix in 6bd00e187 looks like it's the first time this codebase has addressed that bug class at all, and there's no shared sanitization helper anywhere for it. warpgate-protocol-ssh/src/server/service_output.rs, command_detector.rs, and session.rs:584 all write untrusted-ish strings toward a PTY too; worth a pass to check whether any of them have the same exposure now that the pattern's been named once.

Given how many of the above are tests passing without exercising what they claim to, worth doing your own adversarial pass over the test suite specifically, not just the production code, and writing down whatever gaps that turns up so they don't quietly regress later.

The fix for the reserved-name collision moved any name equal to
`admin-token` or `cluster-token` aside, and applied that to every name
reaching the key ID — including the gateway's own. A session driven by
the admin API token started reporting `warpgate:admin-token_:<session>`,
so the fix written to make that string trustworthy changed it.

Two guards caught it by failing their baseline, which is what a baseline
is for.

The two kinds of name are indistinguishable as strings — that is the
defect, not an accident of this code — so the distinction is carried as
data. `IdentityHint` is `Gateway` or `Person`; `key_id_field` does the
colon substitution for both, and `user_key_id_field` adds the
reserved-name substitution for names a person chose. `username()` was
already `None` for exactly the two token variants, so the line was
already drawn and only needed carrying.

The test had checked that the substitution fires and nothing had checked
that it does not fire where it must not. Both directions now.
@janisdombr

Copy link
Copy Markdown
Contributor Author

Rebased on current main (10 commits merged, two conflicts resolved by keeping both sides).

Since the last push this branch went through six rounds of external review by @theredspoon - including one pass with three model providers in parallel - plus our own adversarial agents and a two-provider arrangement where one
side wrote code and the other ruled on whether the evidence was sufficient.

115 distinct defects were raised. 109 are fixed with evidence, 2 are tracked upstream because they are upstream's code, 3 were withdrawn, and 1 falls outside the project's published threat model. Nothing is deferred.

One of the two upstream items was the web-SSH host-key storage bug, reported separately and fixed by @Eugeny in fb66ff7; the fix is merged here.

What the evidence is

tests/mutation_matrix.py disables each of 47 security checks in turn and requires that the test named after that check is among the failures — not merely that something failed. That distinction matters here because the
integration suite runs against a real sshd which independently refuses expired certificates, unknown principals and unrecognised options, so a failed connection proves nothing about our code.

46 of the 47 guards are measured discriminating on the tree as it stands. The artifact is tests/verify-guards.json, stamped with the commit it was produced from.

The forty-seventh is connection: the handshake deadline resumes after a host key answer. Its named test passes with the guard disabled, which means the test is not evidence for that guard. The cause is not yet established - the anchor is unambiguous, the pause and resume both execute on that path, and only the Ignore verification mode short-circuits it - so it is being measured rather than guessed at. Stating it here rather than rounding it up to 47.

Notable fixes in this round

  • The signing CA can now be pinned (vault.ca_public_key). Every other check on Vault's response asked whether the certificate matched the request; none asked who signed it.
  • A username can no longer collide with the gateway's own attribution in the certificate key ID. attribution() puts admin-token in that field, and five of the six paths that create a user did not refuse the name.
  • The handshake deadline is paused for a host-key decision and resumed afterwards, rather than disarmed.
  • The Vault token mutex is no longer held across an unbounded operation.
  • A Vault role is validated when a target is saved, not only when a session
    tries to use it.

Happy to walk through any of these, or to share the full review ledger and audit report if that would help review.

Left

Two minor fixes need to be done. I'll finish them tomorrow

…eadline

Three of these came from CI running the branch for the first time since the
tests were written, and three from an independent verifier told to disbelieve
everything I had claimed. Both found things a week of local work had not.

A compiler crash dump was committed and pushed. `git add -A` took it; nobody
looked. Removed, and .gitignore now refuses it.

A test passed because it ran alone. The jump host in the host-key check was
the file's shared fixture server, which earlier tests connect to — so its key
was already trusted, no refusal happened, and the assertion failed the moment
the suite ran in order. Both hops now get their own freshly started server.
The guard verifier could never have caught this: it runs one named test at a
time, which is exactly the condition under which the test passed.

Validating a Vault role when a target is saved made an older test
unsatisfiable — the API now refuses to create the target whose signing-path
refusal that test proved. The test asserts the save-time refusal instead, and
the composition it used to prove is proven where it still can be: a unit test
that calls `sign_ssh_key` with a traversal role and asserts nothing left the
process. Reading the code and seeing `validate_segment` at the top of that
function is not the same as watching the request log stay empty.

And the handshake-deadline guard, whose named test passed with the guard
disabled. Three experiments, not an argument. With the resume mutated the test
still finished in 37s, so the original bound fired and neither pause nor resume
had run. Replacing the pause with an immediate error left every deadline
assertion passing and broke only the ordinary connection at the end, so the
branch runs for a real target and not for the fixture. The fixture mutes before
NEWKEYS and russh does not call `check_server_key` until the exchange
completes; letting NEWKEYS through trips strict-kex and the client disconnects
in three seconds. That code is unreachable from an integration test.

So the policy is now two named functions and the guard is anchored on the one
that can be got wrong: the answer must put the target's own bound back, not
something longer. That is weaker than an end-to-end proof, and it is stated as
weaker. The end-to-end proof was never there — it was believed to be.
…ence

Three guards named one test. An outside verifier read it and found the
assertion was `target_key != jump_key` — "not the jump host's key", where the
name claims "the target's key". Those coincide for a chain of two and stop
coinciding for a chain of three, so the test proved less than it said.

Splitting them turned out to be the real fix rather than a tidy-up: the guards
need opposite starting conditions. "An untrusted jump host is refused" needs a
jump host nobody has trusted; "the hop is chosen by identity" needs one that is
trusted, or there is nothing to walk through. One test was setting up both
worlds in sequence, which is why making the first half honest broke the second.

The reported key is now compared against the key the target server was actually
started with, type and base64 both. `start_ssh_server` records what it
generated, so a test can name the host that answered instead of eliminating the
one that did not.

Both tests pass alone and in the file. That pair of runs is the check that
matters here: the previous version passed alone and failed in the suite, and my
first attempt at fixing it passed in the suite and failed alone. Neither run on
its own would have caught either.
janisdombr and others added 3 commits August 17, 2026 01:50
Upstream's warp-tech#2437 fixes the same defect this branch fixes — "check host key
returns the jump host's key" — by a different design. Both were in the tree
after the merge, and having both was worse than having either.

Upstream identifies the hop by address: it carries the hop's host and port in
`RCEvent::HostKeyReceived` and matches them in the admin endpoint. This branch
identifies it by the target's id, in `connect_chain`, which also decides where
the walk stops. The identity is the stronger key — two hops can present the
same address, and the address cannot say which one the caller named — so the
walk keeps deciding, and the event keeps carrying the address, which web-SSH
uses.

The address match in the endpoint is dropped, and it is worth saying why
rather than leaving it as harmless redundancy. `resolve_ssh_chain` puts the
asked-about target last, so the address and the id come off the same resolved
hop: the address check can only fail where the identity gate has already
failed. It is not a second opinion. And it had a cost — with it in place, both
jump-host integration tests passed while `reports_host_key()` was disabled,
because the address filter caught what the mutation released. A guard whose
test cannot see it switched off is not measured.

Upstream's `HostKeyUnknown` arm goes with it. It is unreachable here: the walk
refuses an untrusted jump host at the hop and arrives as
`ConnectionError::UntrustedJumpHost`. Keeping a "this is a jump host" comment
on an arm no jump host can reach would misdescribe the code.

Also from this merge: a staged Cargo.lock that named a `thiserror` version no
package entry provided, which would have shipped a lockfile that does not
resolve. Nothing in CI passes `--locked`, so nothing would have caught it.

Verified after the merge: all 47 anchors present, unit tests 56 + 27 + 4
passing, and the clippy deny set clean. The 47-of-47 guard measurement was
taken before this merge; the eight guards whose code it touches are being
re-measured, and the report will say which number came from where.
@janisdombr

Copy link
Copy Markdown
Contributor Author

@theredspoon - every item from your last round, numbered as you numbered them, with the commit that closed it. Three of the twelve were sharper than they looked and one of them was a real memory-disclosure defect I had argued against before measuring it; that argument was wrong and the measurement settled it.

Round I, items I-1 … I-12 - all twelve closed

I-1 · The E-4 fix carries the defect E-4 removed, one stage over.
Correct, and it was our regression, not a pre-existing one. HostKeyUnknown disarmed the handshake deadline unconditionally; the verification mode is decided later, so AutoAccept disarmed it with nobody waiting and Prompt never re-armed it. The deadline is now paused and resumed when the answer arrives, by intercepting the reply channel, which makes the arm mode-independent. 110820c5e, and the guard behind it was rebuilt again in 821e60349 - see the note at the end.

I-2 · The Vault token mutex is held across an operation with no time bound.
Correct. reqwest bounded the POST; nothing bounded building the body, which is where the credential is read - and the lock is deliberately held across the login, so an unbounded read there stalls every session, not one. Now tokio::time::timeout(self.config.timeout, self.login()), with a new VaultError::LoginTimeout mapped to the existing "Vault is currently unavailable" client message so nothing new leaks. 110820c5e.

I-3 · RCEvent::Error bypasses the sanitisation boundary entirely.
Correct, and it was a second path around the boundary, reached through SshClientError::Warpgate which is #[error(transparent)]. One correction to the mechanism you gave: ConnectionError is in fact routed correctly; the leak was the other variant. Both sinks now route through client_error_message() and log the full error while printing a constant. 110820c5e.

I-4 · The matrix names a discriminator that does not exist.
Correct, and worse than stated: the guard was reported on regardless. The matrix now collects every real test name - pytest --collect-only plus cargo test -- --list across every crate that holds a guard - and refuses to start if a named test does not resolve. The missing test has since been written and asserts our own refusal text rather than an exit code. 2a978f72c.

I-5 · Two proptest properties are vacuous on the assertion they exist for.
Correct. \PC is the negation of Unicode category C, so the generator excluded exactly the characters the property asserted the absence of. Changed to (?s).. One was vacuous outright; the other only in its third assertion, so it was repaired rather than deleted. 110820c5e.

I-6 · Measured discriminator count.
Taken. At the time: 42 guards, 25 with a named discriminator, 17 without - and "40 guards" had been cited as a coverage figure, which it was not. The artifact now records the two numbers separately so the total cannot be read as coverage. d482ad7ae.

I-7 · The unattributed key ID loses a field rather than marking itself.
Correct, and the consequence is worse than cosmetic: dropping the middle field shifts the session UUID into the position a reader takes for the username, so a log line naming nobody was indistinguishable from one naming a user called 0e5f…. Now warpgate:unattributed:<session>, with the constant placed beside the field sanitiser that refuses a colon, so no real username can collide with it. 2a978f72c.

I-8 · README documents three refusals; there are four.
Correct. Written. f6d734d4a.

I-9 · The sanitiser is applied per call site, never at the sink.
Correct, and this is the shape that recurs. Escaping moved to both sinks via a dedicated function, separate from the chain renderer's so that a change to one cannot silently alter the other. 110820c5e.

I-10 · read_bounded reserves 32 KiB against a 256 KiB cap.
You were right and I argued otherwise before measuring. My reasoning - that Zeroizing covers the buffer - was wrong, because Vec frees the old allocation itself on growth and Zeroizing only wipes the one that survives. Measured with a canary: a 64 KiB body left one copy of a credential in a freed block. The reader now grows through a function that carries the contents into a new allocation and wipes the old one, and warpgate-vault/tests/zeroization.rs measures it with a control case, because a test that measures a bare Vec instead of the real function would pass while proving nothing. feb4749c2.

I-11 · AWS log silencing covers one crate of three.
Correct. Four crates are silenced now, not one - aws_config, aws_smithy_runtime, aws_smithy_runtime_api, aws_credential_types. 58245a38f.

I-12 · username_is_well_formed has no test.
Correct, and it was the only check in this PR with neither a test nor a guard. It now has both. Following that thread found something you did not raise and we had not seen: the function is private to the admin API and five other paths create a user - SSO auto-provisioning inserts the IdP's preferred_username directly. So the refusal was not on the path that mattered. The name check now lives where the key ID is built, which covers every creation path including ones added later. 39bde2a8f, extended in 37e1c34d8.

That extension then broke what it was protecting, and the fix is worth reading as a pair with it. attribution() puts admin-token in the same field, so substituting every name that matched a reserved one renamed the gateway itself: a session driven by the admin API token started reporting warpgate:admin-token_:<session>. Two guards caught it by failing their baseline. The two kinds of name are indistinguishable as strings - that is the defect, not an accident - so the distinction is now carried as data, and only names that came from a person are substituted. 4bc403677.

The eleven you listed as not verified - all eleven triaged

You closed that round with eleven items marked "I did not verify, and they are not in the findings above". We wrote them into our ledger as prose rather than as rows, which meant they were not counted - and the round was then reported as fully assessed twice, by us. They have now all been checked.

  1. Per-method arithmetic behind VAULT_CALLS_PER_AUTHENTICATION - closed, the constant is right. We filed it as a defect (Azure needing 8 calls against a budget of 5) and withdrew it the same hour: the login is bounded as a whole, so the unit the budget multiplies is a bounded operation and there are four for every auth method. What survived is the comment, which counted HTTP requests and is what misled us. Corrected.
  2. Revoke-on-cancel when a deadline fires mid-login() - closed on the argument. A cancelled login is one whose response was never read, so no token ID is held and nothing can be revoked; if the response did arrive, the token is cached and the next session uses it. The case we had missed is not on the cancel path: the 403 handler clears a cached token without revoking it, deliberately, since a token Vault answers 403 for will likely refuse revoke-self too.
  3. The admin check-host-key persist race - filed, then withdrawn: it was never reachable, on either branch. We recorded that answering HostKeyUnknown with true reaches known_hosts.trust, so previewing an unknown key would pin it before the admin saw the fingerprint, and we said it was upstream's to fix. It is not a defect. check_server_key emits HostKeyReceived at the top of the function, before it reads the verification mode or consults known hosts, and the client forwarded that event for every hop unconditionally - so the admin loop always broke on HostKeyReceived before HostKeyUnknown could be dequeued, for a single hop and for a chain alike. reply.send(true) never ran. We read the two arms of a match and concluded the second one fired, without asking which event arrives first. Found by an independent reviewer checking a merge, two days after we had told the operator it was real.
  4. The admin-token username collision - see I-12 above, including the regression our own fix introduced and how it was caught.
  5. allowed_critical_options first-match against duplicate names - fixed. Every matching pin is enforced now, not the first, with tests for a bare duplicate and for conflicting pins.
  6. role() lacking a chain-membership error - real, unreachable, fixed. A check that names no hop in the chain used to be walked to the end and answered with a live session and no key; it is refused before the first connection now.
  7. The abort branch matching any signal, and its ordering - closed, with one inconsistency noted: two sites treat a dropped sender differently, harmlessly.
  8. Fixed-name temp files in fixtures - one real case, fixed. The other was a path inside a throw-away container, not on the host.
  9. The stub's inability to emit invalid UTF-8 - half closed honestly. The stub still cannot; the parser is now reached by property tests generating arbitrary bytes, which is what the stub could not do.
  10. Untested hostile critical-option values - covered where it bites: an unpinned option is refused outright and a pinned one is compared byte-exactly.
  11. Whether the 120s→35s figure is asserted anywhere - it is: assert elapsed < 60, so the discrimination does not rest on the runner's timeout.
What we found by ourselves after your round

Your round did not end the defects; it changed what we were willing to accept as evidence. Running the guards rather than asserting them found five more defects in the instruments, and the most useful findings since have come from a machine, from CI, and from an outside reader rather than from us.

  • A guard's named test never reached the line it guarded. The handshake-deadline resume. Three experiments, not an argument: with the resume mutated the test still finished in 37 s, so the original bound fired and neither pause nor resume had run; replacing the pause with an immediate error left every deadline assertion passing; and the stalling fixture mutes before NEWKEYS, which russh requires before it calls check_server_key. That code is unreachable from an integration test. The policy is now two named functions and the guard is anchored on the one that can be got wrong. Weaker than an end-to-end proof, and stated as weaker - the end-to-end proof was never there, it was believed to be. 821e60349.

  • A test passed because it ran alone. CI found it the first time this branch was pushed since the test was written. The jump host in the host-key check was the file's shared fixture server, already trusted by earlier tests, so the refusal under test never happened. Our own guard verifier cannot see this class: it runs one named test at a time, which is the condition under which the test passed.

  • One test carried three guards, and proved less than its name. An outside reviewer found that its final assertion was target_key != jump_key - "not the jump host's key" - where the name claims "the target's key". Those coincide for a chain of two and stop coinciding for a chain of three. Splitting the guards turned out to be the fix rather than a tidy-up: they need opposite starting conditions, one a jump host nobody has trusted and one a jump host that is trusted, and a single test was setting up both worlds in sequence. The reported key is now compared against the key the target server was actually started with, type and base64 both. 4954f9d55.

  • The signing CA was never pinned. Every check on the issuer's response asked whether the certificate matched the request; none asked who signed it. signature_key() was read nowhere. It can be pinned now, and an unparseable pin refuses rather than silently checking nothing.

On the merge with #2437, since it solves the same bug

ab876005b landed while this branch was being measured, and it fixes the defect this branch fixes: "check host key returns the jump host's key". Both solutions were in the tree after the merge, and having both was worse than having either. 0df7ae56d keeps one.

Upstream identifies the hop by address, carrying host and port in RCEvent::HostKeyReceived and matching them in the admin endpoint. This branch identifies it by the target's id, in connect_chain, which also decides where the walk stops. The identity is the stronger key - two hops can present the same address, and the address cannot say which of them the caller named - so the walk keeps deciding, and the event keeps carrying the address, which web-SSH uses.

The address match in the endpoint is dropped, and the reason is worth stating rather than leaving it as harmless redundancy. resolve_ssh_chain puts the asked-about target last, so the address and the id come off the same resolved hop: the address check can only fail where the identity gate has already failed. It is not a second opinion. And it had a cost - with it in place, both jump-host integration tests passed while reports_host_key() was disabled, because the address filter caught what the mutation released. A guard whose test cannot see it switched off is not measured, which is the one thing the matrix exists to prevent.

Upstream's HostKeyUnknown arm goes with it: it is unreachable here, because the walk refuses an untrusted jump host at the hop and arrives as ConnectionError::UntrustedJumpHost. Keeping a "this is a jump host" comment on an arm no jump host can reach would misdescribe the code.

The merge also carried a staged Cargo.lock naming a thiserror version no package entry provided, which would have shipped a lockfile that does not resolve. Nothing in CI passes --locked, so nothing would have caught it.

Where the evidence stands, and what you can check from the PR alone. 47 of 47 guards measured discriminating, in one run over one commit. That measurement was taken before the merge above; the eight guards whose code the merge touches are being re-measured, and I will give the two numbers separately rather than one figure for both trees. After the merge: all 47 anchors present, unit tests 56 + 27 + 4 passing, clippy deny set clean.

Worth being straight about what that claim rests on, since you cannot verify it from this diff. tests/mutation_matrix.py is in the PR, so the 47 guards and the test named for each are there to read, and its refusals are there to trigger. The A/B runner that produced the number is not, and neither is the artifact it writes: they belong to how we worked rather than to what Warpgate does, and putting a tool in a PR because it justifies the PR is the wrong reason to ship code. So the number is our report, not something you can reproduce from what is in front of you. If you want either the runner or the run's output, say so and you get them.

Thank you for the round. Several of these were things we would not have found, and I-10 is one we had actively argued against.

@theredspoon

theredspoon commented Aug 18, 2026

Copy link
Copy Markdown

@janisdombr Great teamwork so far, thanks for working on this item with me.

Ran the mutation matrix myself rather than working from the report, all 47 guards, two full runs plus a third targeted rerun on anything the first two disagreed on. Tool issues first, then results, then asks.

Two real issues in mutation_matrix.py itself. Please resolve both. A literal duplicate entry in DISCRIMINATES ("admin: a Vault role the signing path would refuse is refused on save", byte-identical both times) trips the script's own duplicate-entry check, so as committed it refuses to run at all. Separately, the documented invocation (python -m tests.mutation_matrix, no flag) reruns the entire three-file suite plus every crate's full test binary per guard; the fast, targeted A/B behavior your report describes is behind an undocumented --named flag, which needs to actually be documented as the real invocation.

One correction, no action needed: "the A/B mutation-testing runner... is not in the PR" isn't accurate. mutation_matrix.py is a complete, working A/B runner, I ran it. What's actually gone is a different, separate file (tests/verify_guards.py/verify-guards.json, deleted in an earlier commit). The current artifact path is tests/mutation-matrix.json.

Results, all 47, and what each group means for you

35 discriminate cleanly, stable across every run. No action needed on these.

11 guards' tests failed because of test-harness fragility, not application bugs. Please resolve both root causes.

  1. Hardcoded localhost resolves to ::1 in some Docker networking setups, this one included, where only 127.0.0.1 answers. Affects make_user_and_target (tests/test_ssh_target_cert_auth.py:113) and everything built on it, including target_on() (tests/test_vault_hostile_target.py:79-82). Pin to 127.0.0.1 explicitly.
  2. The ssh-keys bind mount isn't routed through a configurable path. ProcessManager.start_ssh_server (tests/conftest.py:234) does -v {os.getcwd()}/ssh-keys:/ssh-keys, relative to wherever the repo is checked out. A Docker setup requiring explicit directory allow-listing gets a silently empty mount, sshd has no host key, every target container fails to start. Route it through the same configurable tmpdir mechanism the rest of the fixtures already use.

All 11 confirmed directly, not guessed: fixing both turned every baseline-red and merge-touched guard green; for the 3 previously reported as "does not discriminate" or unstable across runs, re-running the guard's own mutation against a rebuilt binary with the fix applied gave a clean, deterministic A/B result. None of these are application defects, and none of the 3 misreported ones are real coverage gaps.

All 11 guards, discriminator test, and how each was impacted
Guard Discriminator test How it was impacted
certificate: a target refusal names the validity window test_certificate_that_is_not_yet_valid baseline red, both causes
certificate: unexpected critical options refused test_an_unexpected_forced_command_is_refused baseline red, both causes
connection: the inter-hop tunnel open is bounded test_a_jump_host_that_never_opens_the_tunnel_is_given_up_on baseline red, both causes — the guard itself was already firing correctly in the log; only the test's trailing sanity check needed the fix
vault: wrapping token redeemed once test_a_wrapping_token_is_redeemed_once_and_the_secret_id_reused baseline red, both causes
certificate: an already-expired certificate is refused test_expired_certificate baseline red, both causes
connection: an untrusted jump host is refused, not traversed merge-touched, cause #1 — generic 500 became the correct refusal once fixed
host key: the hop is chosen by identity, not by position merge-touched, cause #1
host key: only the hop that was asked about reports merge-touched, cause #1
auth: a token is not attributed as a person test_checking_a_chained_target_authenticates_only_to_the_jump_host reported "does not discriminate," actually cause #1 — the guard-off run failed on an unrelated connection-refused, which read as a pass
certificate: key ID must match test_a_certificate_with_a_different_key_id_is_refused reported unstable across 3 runs, actually cause #1
certificate: must certify our ephemeral key test_certificate_issued_for_a_key_warpgate_does_not_hold reported unstable across 3 runs, actually cause #1

One real ask that fell out of chasing the sandbox issue. warpgate-protocol-ssh/src/client/error.rs:51 maps every russh::Error, including a plain connection-refused, to the identical "SSH protocol error" string on the admin API. That's fine for an SSH client mid-session, sanitizing detail from an untrusted party makes sense there. It's not fine for the admin host-key-check endpoint specifically: the caller there is an authenticated operator who needs to know whether "I can't reach this jump host at all" and "this jump host's key is untrusted" are the same problem or two very different ones requiring different responses.

Please resolve by giving that endpoint's response a way to distinguish connection-establishment failure from an actual host-key rejection, even if the generic message stays for every other caller.

The rest of the round, verified directly against code rather than the write-up

Five confirmations, no action needed:

  • Admin host-key-check address-match: replaced by chain-identity matching (role() vs check_target), stronger than the write-up's own claim.
  • Admin check-host-key persist race (I-3): no race — HostKeyReceived fires before known_hosts.validate is ever called.
  • Cargo.lock/thiserror: clean, cargo check --locked succeeds.
  • I-12 regression and fix (39bde2a8f, 37e1c34d8, 4bc403677): confirmed both ways — reverted and reran, failed as expected, restored.
  • Order-dependent CI test: a shared module-scoped fixture was leaking trust state; the fix gives it independent servers.

Two asks:

  • 821e60349's test doesn't cover what it claims to. Please resolve. The handshake-deadline-resume fix itself is real, genuine pause-and-restore rather than the earlier disarm-and-never-rearm bug. But its only test is a unit test that checks two constants are internally consistent, it never calls the actual .reset() at the real call site. "Weaker than an end-to-end proof" undersells that, it's not close to e2e, it doesn't touch the call site at all. A future edit that calls the right function with the wrong value, or forgets to call it, would still pass.

Extend the test to exercise the real call site, not just the constants.

  • Signing-CA pinning has no commit hash. Please add one. The fix itself is real and fail-closed on an unparseable pin (found in client/mod.rs), but it's the only item in the write-up without one, worth fixing for consistency with everything else being tracked.

@theredspoon

theredspoon commented Aug 18, 2026

Copy link
Copy Markdown

Full reconciliation against our original 33-item review, everything raised since, and one earlier item — checked directly against code at the current head (0df7ae56).

Confirmed fixed / resolved / correctly withdrawn — no action needed (click to expand)
  • Item 1 — handshake/prompt deadline pause/resume is genuinely mode-independent.
  • Item 2 — the 120s→35s timing figure is now actually asserted (< 60), tighter than every surrounding timeout layer.
  • Item 3 — guard-count headline no longer overstates completeness; the artifact separates total from covered.
  • Item 6 — the critical-options test's write-up now accurately describes its own mechanism (fake names, refused on Warpgate's own message) instead of a force-command switch that never happened.
  • Item 7VaultClient::token()'s mutex is fully bounded by tokio::time::timeout, covering the credential read and AWS SDK chain, not just the final HTTP call.
  • Item 9 — the deadline disarm fix is genuinely mode-independent, covering Prompt as well as AutoAccept.
  • Item 10VAULT_CALLS_PER_AUTHENTICATION's arithmetic checks out; it bounds operations, not raw HTTP requests, and the comment now says so.
  • Item 12 — the admin check-host-key persist race was correctly withdrawn; genuinely unreachable, confirmed via event ordering.
  • Item 13 — the admin-token/username collision and its own regression are both fixed and confirmed both ways (reverted and reran).
  • Item 16 — AWS log filter now covers all four relevant crates.
  • Item 17 (mechanism) — the buffer-wipe-on-grow fix is real and correctly eliminates the reallocation window.
  • Item 20 — all four originally-flagged unescaped PTY sinks are covered by the sink-level escaping fix.
  • Item 21RCEvent::Error's sanitization-boundary bypass is fixed; both sinks route through the same function now.
  • Item 23allowed_critical_options duplicate handling no longer resolves by first-match; every matching entry is enforced.
  • Item 24 (partial) — hostile critical-option values are tested (unlisted refused outright, pinned compared byte-exactly).
  • Item 25 — the phantom-test guard now has a real, existing, collection-verified discriminator.
  • Item 26 — both vacuous proptests fixed; the two untouched \PC-using tests were correctly left alone.
  • Item 27 — the credential-stream-cap test now isolates the stream bound with a real FIFO; the redundant stat-based guard was removed after being shown never to diverge from it.
  • Item 30 — all 47 guards now have a registered discriminator.
  • Item 31 (first half) — the :-rejection in username validation now has real tests and a mutation guard.
  • Item 32role()'s chain-membership guard is genuinely the first statement in connect_chain().
  • Test-conflating-three-guards (raised separately) — real flaw confirmed, now split with opposite starting conditions and exact-match comparison.
  • resolve_ssh_chain target-last / fixed #2412 - "check host key" returns jump host's key #2437 merge safety (raised separately) — the ordering guarantee behind dropping the redundant address check holds structurally.
  • Stub UTF-8 (item 24, error-body half) — a real arbitrary-byte property test now exists and exercises a real shipped parser (though see below — not the one originally named).

Still open

Item 8: authentication_budget also bounds the target's own USERAUTH response, unbounded. Unchanged since first raised. Traced the actual math: max(30s, vault.timeout × 5 + 5s), and vault.timeout has no upper clamp in config — so a target that goes silent right after receiving its certificate holds the session, ephemeral key, and live certificate for a window sized for Vault's slowness, not the target's. Default config: 55s. A generous vault.timeout scales this with no ceiling.

Ask: give the target's own USERAUTH response its own, separately-bounded timeout, independent of vault.timeout.

Item 15/18: two small key-material hygiene gaps. key_id_field still does a plain .replace(':', "_")root:admin and root_admin still collide, and the doc comment claiming "no real username can collide" is factually wrong about what the function does (it substitutes, it doesn't reject). Separately, VaultAuth::AppRole's role_id is still a plain String, unlike every sibling secret in the same struct.

Ask: either reject : in usernames the same way the admin API now does for locally-created ones, or use an encoding that can't collide (percent-encoding, or escape _ first). Wrap role_id in Secret<String> for consistency — Vault itself treats it as non-secret, so this is hygiene, not an active leak.

Item 19: 13 more fixed-name temp-file instances beyond the two originally fixed. warpgate-vault/src/client.rs (11 sites) and warpgate-vault/tests/zeroization.rs (2 sites) — all PR-added test code — still build host paths with only a process ID for uniqueness, several writing real credential material.

Ask: apply the same tempfile-based fix already used for the recordings path to these 13 sites.

Item 22: Vault error bodies and host strings both still reach logs unescaped, at 7 sites (one more than originally counted). Confirmed the mechanism precisely: every affected site uses %field/Display, and a \n in a Vault error body or an unresolved host string genuinely forges a log record in the default text format — confirmed by contrast against sites already using ?field/Debug, which does neutralize this. The I-9 PTY-sink fix doesn't help here; no tracing call routes through it. One more site than originally named: session.rs:436's emit_service_message debug-logs the raw message before the I-9 escaping runs. Separate good news: the key_id_field-colon part of this same finding turned out to already be covered — validate_key_id independently rejects control characters fail-closed before any key ID reaches Vault or gets logged, so that specific path doesn't actually forge anything.

Ask: switch the listed sites from %/{} to {:?} for Vault error bodies and host strings, matching the pattern already used elsewhere in this file.

Item 24: the never-expiring-certificate test is still skipped. Same skip reason as when first raised. Worth knowing even if unskipped: its current assertions (exit code + absence of one string) wouldn't check Warpgate's own refusal message anyway, so unskipping it as-is wouldn't fully close the original gap.

Ask: un-skip the test, and strengthen its assertions to check Warpgate's own refusal message (not just exit code) while doing so, so unskipping actually closes the gap rather than reopening a weak one.

Item 28: test_token_zeroizing still only tests the zeroize crate's own Deref, not any Warpgate code path.

Ask: delete it as redundant, or rewrite it to assert something Warpgate-specific.

Item 29: the hostile-option-name terminal test still lacks a positive anchor. Every sibling test in the same file already uses the pattern ("Warpgate refused the certificate" in shown); this one's the outlier.

Ask: add the same positive anchor the sibling tests already use.

Item 31 (second half): the admin host-key-check endpoint's error sanitization has no test at all. Bonus finding while checking: the nearest existing test that could plausibly cover this is actually non-discriminating for it — it asserts a string that also appears in an unrelated error variant's Display text, so it would pass identically with sanitization removed.

Ask: add a test that actually exercises this endpoint's sanitization specifically — asserting that raw internal error text can't reach the admin API response, using a case the existing non-discriminating test doesn't cover.

Item 33: the abort-branch ordering isn't actually fixed, and "harmlessly" undersells a real divergence. The _ matching pattern is correct now and documented. But Done still gets sent before the abort reason is returned — exactly the ordering the sibling HandshakeTimeout branch's own comment, a few lines above, explains why to avoid. Separately: the two sites that treat a dropped sender differently aren't equivalent-but-different, they diverge in kind — one would hang forever on that input (no live bug today, since every current owner sends before dropping, but not equivalent to the other site tearing down cleanly).

Ask: reorder so the reason is captured before Done is sent; either make the command-loop site match the same catch-all pattern, or document why it deliberately doesn't.

Mutation-matrix CI wiring: not in any .github/workflows/ file. Right now "guards discriminate" is true only at the moment someone runs this by hand and reports it — nothing stops a future change from silently breaking a guard's test coverage.

Ask: run it in CI (even just --named mode, given the full mode's cost) and fail the build on anything short of full discrimination. That's the durable version of "checkable without rerunning it" — a green check, not a committed snapshot.

Item 17 (residual): I-10's test guards the buffer-growth helper, not the real call site (already self-disclosed honestly). Plus two smaller residual unzeroized paths found while checking: error_body's 256-byte Vault error buffer, and read_bounded_json's serde_json scratch buffer (both low severity, bounded).

Ask: exercise read_bounded directly in the zeroization test, or extend the honesty note to name the call site explicitly. Zeroize the two residual buffers too, for consistency, even though the severity is low.

Doc nits: README's certificate_ttl table lead-in still says "Three cases" above what's now a four-row table. And the comment at client.rs:132-133 — "Revocation is still handled: a rejected token is dropped when signing comes back 403" — conflates cache invalidation with actually revoking at Vault; the reasoning for why that gap is fine (a 403'd token would likely fail revoke-self too) doesn't appear in the code, only in the write-up.

Ask: fix the README lead-in to say "Four cases"; rewrite the client.rs comment to describe cache invalidation accurately, and consider inlining the revoke-self reasoning so it's not only in this thread.

Commit attribution: two small mismatches (I-4, I-6) — content's right, just cited to the wrong commit (both actually landed in 110820c5, not 2a978f72c/d482ad7ae).

Ask: repoint the references if it matters for your own tracking — not required, content is correct either way.

One more precision correction. "A Vault role is validated when a target is saved" reads broader than what shipped. What's actually there is a syntactic name-format check, shared between the admin save path and the connect-time path — real, and a genuine improvement, but not a check that the role exists in Vault. warpgate-admin has no dependency on warpgate-vault and there's no role-lookup API to call one even if it did. A syntactically valid but nonexistent role still saves successfully and only fails at connect time — the original "fail-closed, reasonable follow-up" framing is still the accurate description.

Ask: narrow the claim to "the same name-format rule now applies at save time and connect time" — the current phrasing implies more than it delivers.

@theredspoon

Copy link
Copy Markdown

One more thing, unrelated to the code itself. An earlier comment on this PR (issuecomment-5271487696) included a process suggestion — a 'maker-check' pipeline (separate RED test-builder/verifier, GREEN implementer/verifier, and deterministic re-checks run by orchestration rather than folded into any subagent's own judgment, plus multi-provider adversarial review before merge). It hasn't come up since, so mentioning it again in case it got lost in everything else this thread covered — no pressure either way, just didn't want it to go unseen.

A recurring pattern across this review was tests that turned out not to exercise what they were named for — not a guarantee every one of them would've passed with the guard removed, but enough of a pattern that it seems worth naming. Some of that may be exactly what the pipeline above is aimed at. Some of it might just be the sheer size of this effort — a diff and a review this large probably pushes against context limits on both sides, which can cause the same kind of thing independent of process. Likely some mix of both, but wanted to flag that there's something structural here worth thinking about, beyond any individual finding.

The duplicate-entry check added after a repeated `DISCRIMINATES` key silently
won caught a repeated key — this one, byte-identical to its neighbour, both
naming the same test. So the file as committed exited before running anything,
and the reviewer who tried it had to work around that to get a number.

The docstring also led with the expensive mode. Without `--named` the script
asks which of *all* the tests notice a mutation, rerunning the whole integration
suite and every crate's unit tests per guard; `--named` runs only the test named
after the guard, twice. The published coverage number comes from the second, so
that is the one the usage block now shows first.
…ution

Two causes, between them, made eleven guards report as not discriminating in a
reviewer's environment. Neither was an application defect, and both are the same
shape: the test harness reaching outside itself for something it could carry.

The sshd container was handed `-v {os.getcwd()}/ssh-keys:/ssh-keys`, a host path
assembled from wherever pytest happened to start. A Docker setup that
allow-lists which host directories may be shared mounts that as empty rather
than refusing, so sshd came up with no host key and every target container died
before the test it was started for could say why. The shared host key is now
copied into the per-server directory that was already being mounted, and the
second mount is gone.

Targets were configured as `localhost`. Warpgate resolves that and dials the
first address it gets, which on a dual-stack host is `::1`, while the containers
publish on v4 only. That failure is worse than a red test: a guard-disabled run
fails for the same reason a guard-enabled one does, which reads as the guard
being caught. The fakes in this suite were already made dual-stack for this
reason — an instance fix, since nothing can make a published Docker port answer
on `::1`. Targets now name `127.0.0.1`.

The matrix documented a command that does not run: `poetry -C tests run`
executes with the working directory already changed to `tests/`, at which point
the `tests` package is not importable and `-m` fails. Found while producing
evidence that the previous commit worked, which is the only reason it was found.

It also paid the whole repository's collection cost for a single guard — a
pytest collection plus one `cargo test --list` per crate, before any mutation.
Twenty-six minutes without reaching a verdict, measured. Collection is now
scoped to the selected guards' crates, and a name missing from that narrow set
is still looked for everywhere before it is called missing.

The hostile-option-name terminal test asserted only that no escape sequence
reached the terminal, which a connection dying before it wrote anything
satisfies too. It now anchors on the refusal and on the option name, so the
three cases it could not previously tell apart are separated.
… be forged

The key ID is the whole point of this feature: it puts the Warpgate user's name
in the target's own sshd log, so a session is attributable to a person. It was
built by replacing `:` with `_`, which maps `root:admin` and `root_admin` onto
one field — the log then names someone who did not connect. Percent-encoded now,
`%` first so a literal `%3A` cannot read back as a colon.

The comment above it claimed the function *rejects* a colon. It never did. That
sentence was also the reason given for `UNATTRIBUTED` being safe from collision,
and that reasoning was doubly wrong: what threatens `UNATTRIBUTED` is a user
named `unattributed`, which colons have nothing to do with, and `user_key_id_field`
held only `TOKEN_ATTRIBUTIONS` — two lists of reserved names, one consulted. Both
now go through `is_reserved_key_id_field`, so a third name cannot be added to
only one place.

The existing colon test asserted `fields[1] == "root_admin"`, pinning the
collision as correct behaviour. It counted fields, which a substitution mapping
two people onto one name satisfies perfectly.

Both new guards were A/B'd by hand rather than argued: with the encoding reverted
`two_usernames_cannot_collide_in_a_key_id` fails, with the `UNATTRIBUTED` half of
the predicate removed `a_username_cannot_impersonate_the_unattributed_placeholder`
fails, and both pass restored.

Separately, nine log sites carried a remote party's words through `Display`. A
newline in a Vault error body, an unresolved host name or a certificate's option
name forges a whole record in the default text format, indistinguishable from one
Warpgate wrote. `Debug` escapes it. `emit_service_message` was the worst of them:
it logs the message raw, before the PTY escaping that the same text goes through
on its way to the terminal. Seven were reported; the other two are usernames from
target configuration, which need admin access to exploit and are the same class,
so they went too.
…the process

Thirteen paths, not the two that were reported and fixed: eleven in the Vault
client's tests and two in the zeroization test. Each was `wg-<label>-<pid>` under
the system temp directory — one name per test rather than one per run, so two
runs at once clobber each other, a crash leaves the file behind, and several of
them hold a real credential under a name anyone who read the file can guess.

A `tempfile::TempDir` per test now, removed when the test ends. `tempfile` was
not a dev-dependency of this crate; it is one of the binary crate's, which is
where the recordings-path fix took it from.

Measured while verifying this: `cargo test -p warpgate-vault` spends 250 seconds
in the library tests alone. The mutation matrix runs exactly that as its startup
precondition, on every invocation, before it checks anything at all — so it is
paid for a guard in any crate, and it is the largest single part of what makes
the instrument read as too expensive to run.
The abort branch was reported fixed once already: the catch-all pattern was
corrected and the ordering it was raised for was left in place. It still called
`set_disconnected()` before returning, which sends `Done` ahead of the reason on
the same channel — the exact ordering the `HandshakeTimeout` branch a few lines
above avoids, and explains in its own comment why. The command loop's sibling
matched `Some(())`, which is not another spelling of `_`: a closed `abort_rx`
disables that branch instead of firing it, and with the event branch disabled
too there is nothing left for `select!` to wait on.

`role_id` is now `Secret<String>`. Vault calls it public and it is half a
credential, but every other field in that enum that takes part in an
authentication is redacted, and a rule is worth more than a judgement about
which halves matter. `config-schema.json` is byte-identical — verified by
regenerating it. The rationale is a `//` comment: as `///` it landed in the
published schema as operator-facing documentation, which it is not.

`error_body`'s buffer is `Zeroizing` and reserved at its own bound, so it
neither grows nor survives unwiped. `read_bounded_json`'s `serde_json` scratch
is not fixed and cannot be from here — those allocations are inside the parser.
Recorded as a limit rather than closed.

`test_token_zeroizing` is deleted. It built a `Zeroizing<String>` and asserted
`as_str()` returned what was put in — the zeroize crate's own `Deref`, no
Warpgate code in it, under a name that read as coverage of the token cache.

The buffer-growth zeroization test now names what it does not cover: it drives
the helper, not `read_bounded`, so a reader that stopped calling the helper
would leave it green. Driving `read_bounded` needs a `reqwest::Response`, which
would put hyper's buffering inside the window this file's allocator watcher
measures, and the canary would be counted in someone else's freed memory. A
test that fails for a cause we cannot fix is worse than one that says what it
does not check.

The comment claiming revocation is handled now says what happens: a `403` drops
the cached token so the next call re-authenticates. That is cache invalidation.
Warpgate never calls `revoke-self`, and the reason — the one moment it would is
just after Vault refused the token, when the revoke would as likely be refused —
lived only in a review thread.
Four commits: the beta bump, RDP clipboard redirection (warp-tech#2447), the duplicate
admin role on a rerun of setup (warp-tech#2443), and the admin's new-tab preference
(warp-tech#2396). Nothing they touch overlaps this branch's work — the one file in
common, warpgate-protocol-ssh/src/client/mod.rs, they reformat a single line in,
far from anything here.
`authentication_budget` covered the whole step, including the target's own
USERAUTH reply, and for a certificate target it grows with `vault.timeout` —
which config does not clamp from above. A target that went quiet the moment it
received its certificate held the session, the ephemeral private key and a live
certificate for a window measured in the issuer's slowness: 55 seconds by
default and unbounded in principle. The target's answer now has a flat
thirty-second bound of its own, applied at all five USERAUTH call sites so a new
credential type cannot arrive unbounded by being forgotten, which is how this
opened. `TargetAuthenticationTimeout` names the party that went quiet, the way
`TunnelOpenTimeout` already does.

The bound is a parameter rather than a constant read from a paused clock. The
first version used tokio's `test-util` feature for that, which changes feature
unification across the whole build: the A/B measuring it exceeded a fifteen
minute cap without once reaching the test. A parameter costs a line.

The admin host-key-check endpoint rendered `SSH protocol error` both for a host
that could not be reached and for one whose key is not trusted. Same sentence,
two entirely different jobs, and its caller is an authenticated operator rather
than an untrusted party. `Io` and `russh::Error::IO` now render through
`unreachable_reason(kind)` — the kind, never the operating system's own string,
because this is the sanitiser and a fixed set of phrases cannot carry anything
through it.

That sanitiser had no test at all, and the nearest candidate asserts a string
that also appears in an unrelated variant's `Display`, so it passes with the
sanitising removed. Two now: one builds an error carrying `relation
warpgate_user column password_hash` and requires it not to appear, the other
requires unreachable and untrusted to differ and the reason still to be named.

All three guards were A/B'd: each named test passes with its guard on and fails
with it off. The runner was rewritten first — the previous one read the exit
code, which cannot tell a caught guard from a killed runner, and restored only
in `finally`, which a signal skips. It left a mutated source behind once.

Repointing `key_id_field` for the earlier commit also left an existing guard
anchored on a line that no longer exists. `check_anchors` refused and nothing
else would have noticed; a guard with a stale anchor is reported as measured
while never once being disabled.
`answering_a_host_key_question_puts_the_targets_own_bound_back` compared
`once_the_host_key_is_answered()` against `HANDSHAKE_TIMEOUT` and called neither
of the functions that move the deadline. It would have passed with either call
site deleted, and with the two durations swapped between them — the two
regressions it exists to catch. Raised externally, and "weaker than an
end-to-end proof" undersold it: it did not touch the call site at all.

The pause and the resume are now named functions with one implementation each,
and the test holds the real `Sleep` the connect loop holds and moves it through
them: the pause goes beyond a day, the answer shortens it, and what it comes
back to is no longer than the target's own bound.

The guard's anchor moved with it, from the constant to the call. On the constant
it was mutating a value the test compared against another value, so the pair
agreed with each other while nothing established that either was ever called.

What this still does not prove is that the `select!` arm calls them. Nothing on
that path can: russh does not invoke `check_server_key` until the key exchange
completes, and the stalling fixture mutes before `NEWKEYS`, which was measured
twice and is recorded beside the code.
…refused

Un-skipping the integration test that had been parked on "holds the session open
for ~45s for a reason not yet isolated" isolated the reason. It was a panic.

`humantime`'s `Display` returns `Err` rather than truncating for any time at or
after the year 9999, and `to_string()` panics when a `Display` errors. The
validity window is rendered for the diagnostic message *above* the call to
`certificate_mismatch`, so `ssh-keygen -V always:forever` — and equally a Vault
role with no TTL, or one with an absurd `max_ttl` — killed the tokio worker
mid-connection.

Three consequences, all of them observed rather than reasoned about. The guard
that refuses a never-expiring certificate could never run, because it sits after
the line that panicked. The client was left holding a connection nobody would
ever answer, which is the session-hold class again. And it is reachable by the
issuer, which this design treats as hostile throughout.

`describe_certificate_time` is now a function rather than a closure, formats
through `write!`, and says so where humantime gives up. Its test drives the
first second of the year 9999 and also asserts an ordinary expiry still renders
as a date, so a fix that describes everything as unrenderable fails it.

Nothing caught this because the unit test for the never-expiring guard calls
`certificate_mismatch` directly and never reaches the panicking line, while the
one test that did reach it was skipped on the symptom the panic produced. A test
disabled because of the defect it had found.

The integration test now passes end to end in seven seconds against a real sshd,
asserting Warpgate's own refusal names the never-expiring window — not merely
that the connection failed, which every other failure would satisfy too.
My own assertion from the previous round was wrong and the run said so:
`ssh-keygen` splits `critical:NAME=VALUE` on the first `=`, so the value is not
part of the name the refusal quotes. It asserted `HACKED=x`; the message carries
`HACKED`.

Correcting it exposed the weaker point behind it. "The option name is present"
and "no raw escape reached the terminal" are both satisfied by a fix that
silently strips the option name from the message. The escape now has to appear
in its inert form, `\u{1b}[2J`, so removal and escaping are told apart — which
is the only thing this test exists to distinguish.

Sixteen of sixteen in the hostile-certificate suite against a real sshd.
Until now "the guards discriminate" was a statement about the last afternoon
somebody ran the matrix by hand and reported a number. Two of the numbers
reported that way were wrong. A green check is the durable form of that claim.

`--changed <base>` selects the guards whose anchor file a change touched, and
prints how many of how many: a run that reports on a subset has to name the
subset, or a green check implies a sweep that never happened. The workflow runs
that subset on `pull_request` and all fifty-three on a schedule and on dispatch,
the second being the backstop the first leans on. `fetch-depth: 0`, because
`--changed` has nothing to diff against otherwise; the script names that case
rather than reporting zero changed guards.

A guard with no named discriminator fails the build, and every guard that did
not discriminate is now named in the failure rather than only counted.

The `cargo test -p warpgate-vault` precondition is skipped in `--named` mode.
It runs that whole suite — 250 seconds, measured — on every invocation to
establish the tree passes before anything is mutated, and `--named` establishes
the same thing per guard and more narrowly: the named test must pass *before*
the mutation, or the guard is reported `already failing` and no verdict comes
out of it.

A sweep also printed nothing until every guard had finished, so an hour-long run
was indistinguishable from a hung one by anything except `ps`. Two of today's
runs were killed for exactly that reason. Each guard now reports as it lands.
Two sweeps ran side by side today. The lock was written unconditionally rather
than checked, so the second started happily beside the first and both rewrote
the same source files — two mutations live at once, and any verdict either
produced would have been about a tree neither of them described. It was noticed
by counting processes, which is not a control.

A lock left behind by a killed run now blocks a start too, and that is the right
way round: a stale lock costs one command to clear, and the refusal names both
the command and the check to run first. A contaminated sweep costs a number that
looks like evidence.

The pre-checks were also silent. They build a test binary per crate and run two
workspace-wide `cargo check` passes, which together ran for over an hour on a
full sweep while printing nothing at all — so the run was indistinguishable from
a hung one, and three of today's were killed on exactly that ambiguity. Each
phase now announces itself before it starts.
Raised by the auditor from the sweep's own output, which is the first defect
this round found by watching an instrument run rather than by reading it.

`verify_named` restored the mutated *source* in its `finally` and never rebuilt
the binary compiled from it; the clean rebuild happened once, after the whole
loop. So `target/debug/warpgate` carried the last mutation applied to it, and
every integration guard's baseline ran the previous guard's mutated gateway. It
surfaced as `already failing` on two guards of a sweep whose tree was clean.

The consequence is wider than those two. A `discriminates` verdict asserts the
named test passed before the mutation and failed after it, and the first half of
that was measured against someone else's mutation. The verdicts from that sweep
are void rather than partial, and it restarts rather than resumes.

The gateway is now rebuilt from clean source before a baseline that needs one,
and only then — which turns out to be 16 of the 53 guards. The other 37 are
pinned by Rust unit tests, which `cargo test -p` compiles itself and which never
look at `target/debug/warpgate`; building it for them was work whose result
nothing read.

Each guard now prints `baseline`, `building the gateway with the guard off`, and
`testing with the guard off` as it reaches them, so a slow guard is legible as
slow rather than as stuck.

`--fail-fast` stops at the first guard that does not discriminate and says how
many remain **unknown, not passing**. Guards added or repointed in round J are
ordered first, so that flag reaches the least-established ones in minutes rather
than hours. Order changes no verdict: every guard is measured against its own
baseline.
The claim this feature's test suite has been asked to support since the first
review round, produced in a single sweep rather than assembled from separate
afternoons: each guard disabled in turn, and the test named after it failing
exactly when the guard is gone and passing when it is not.

`tests/mutation-matrix.json` carries `partial: false`, `refused: null`, and 53
results of one status. Fourteen hours and twenty minutes, of which roughly
fifty-five minutes were pre-checks.

The number is worth what the instrument is worth, and this round found six
defects in the instrument. It would not start at all — its own duplicate check
tripped on a duplicate. Its documented invocation ran from nowhere. It paid the
whole repository's collection cost for a single guard, in three separate places.
It printed nothing for hours, so three runs were killed as hung. Two runs
executed side by side rewriting the same files, because the lock was written
rather than checked. And the mutated gateway leaked into the next guard's
baseline, which invalidated 28 verdicts of the preceding sweep — found by the
auditor, from a running sweep's output, not from reading the code.

All six were fixed before this run, and the run started from scratch rather than
resuming, because a sweep half-measured against another guard's mutation is void
rather than partial.

Guards 29 and 31, the two that produced the false `already failing` that exposed
the last of those defects, pass cleanly here.

What this does not say: that these are the right guards, or that the set is
complete. Neither claim is made.
Fourteen commits, of which the release itself, a vt100 upgrade fixing a resize
panic, pubkey failures counting towards IP blocking, and a session view linking
to its user and target.

Only the two generated OpenAPI schemas conflicted, and they were regenerated
from the merged Rust types rather than resolved by hand. That was not
ceremony: taking either side wholesale would have dropped upstream's new
`user_id`, `target_id` and `remote_address` fields out of the published API,
silently, because a hand-resolved generated file corresponds to no version of
the types it is generated from.

`warpgate-protocol-ssh/src/server/session.rs` merged cleanly despite both sides
editing it.

No guard's anchor file is touched by any of it, so `--changed` selects nothing
and the 53-guard sweep is not repeated. That rule cannot see a guard broken in a
file the change did not touch — which is why the scheduled full sweep exists —
so the integration suites are run instead, at ten minutes against fourteen hours.
@janisdombr

Copy link
Copy Markdown
Contributor Author

@theredspoon Pushed — 46 commits, merged with v0.28.0, 0 behind main.

Twenty-one of your twenty-five asks are closed and no product code is open. But the thing worth your time is not in your list.

Un-skipping one test found a panic

Item 24 asked me to un-skip test_a_certificate_that_never_expires and strengthen its assertions. I did, and it failed — not on an assertion, on a client timeout. The gateway's log ended at "Certificate carries extensions" with no refusal, and then:

thread 'tokio-rt-worker' panicked at library/alloc/src/string.rs:2943:14:
a Display implementation returned an error unexpectedly: Error

humantime's Display returns Err rather than truncating for any time at or after the year 9999 (humantime-2.4.0/src/date.rs:267), and to_string() panics when a Display errors. The validity window is rendered for the diagnostic message above the call to certificate_mismatch. So ssh-keygen -V always:forever — and equally a Vault role with no TTL, or one with an absurd max_ttl — killed the worker mid-connection.

Three consequences, all observed rather than reasoned about:

  • the guard that refuses a never-expiring certificate could never fire, because it sits after the line that panicked;
  • the client was left holding a connection nobody would ever answer, which is item 8's class again;
  • it is reachable by the issuer, which this design treats as hostile throughout.

Why nothing caught it: the unit test for that guard calls certificate_mismatch directly and never reaches the panicking line, and the one test that did reach it was skipped on the symptom the panic produced — "holds the session open for ~45s for a reason not yet isolated". A test disabled because of the defect it had found, parked for a week.

That is your closing observation, one level deeper than you put it. Fixed in 859fe990c, with a guard that drives the first second of the year 9999 and also asserts an ordinary expiry still renders as a date, so a fix that calls everything unrenderable fails it.

Four claims of mine were wrong

  • Item 22's count. I said nine sites, "verified by grep". The grep required % on the same physical line as the macro and could not see multi-line tracing! calls. It is eleven. A check I chose, ran and reported on myself, which passed because it could not see the thing it was looking for.
  • Why item 24 was skipped. I said the missing PTY meant the refusal had nowhere to go. Wrong — the task had panicked. The log said so; my reading of the code did not.
  • "HACKED=x" in the terminal-escape test. ssh-keygen splits critical:NAME=VALUE on the first =, so the value is not part of the name the message quotes. Correcting it exposed the better point: "the name is present" and "no raw escape arrived" are both satisfied by a fix that strips the name entirely. The escape now has to appear in its inert form, \u{1b}[2J.
  • A mutation-matrix run I reported as measuring something. It was killed, and a killed runner's exit code is indistinguishable from a caught guard. Recorded as not run.

Your list

Item 8 — the target's USERAUTH answer has a flat 30s bound of its own, applied at all five call sites so a new credential type cannot arrive unbounded by being forgotten. New TargetAuthenticationTimeout variant, so the party that went quiet is named.

Item 15/18 — key_id_field percent-encodes (% first, then :). Reading the comment you flagged as false turned up a second defect it was concealing: UNATTRIBUTED goes into the same field and user_key_id_field consulted only TOKEN_ATTRIBUTIONS, so a user named unattributed was indistinguishable from a session with no user recorded. Two lists of reserved names, one consulted. role_id is Secret<String>; config-schema.json is byte-identical, verified by regenerating.

Item 19 — thirteen sites, all through tempfile::TempDir. Item 28 — deleted; it tested the zeroize crate's own Deref. Item 29 — anchored, and corrected as above. Item 33 — the reason is captured before Done, and the command loop's Some(()) is now _; those were not two spellings of one thing, since a closed channel disables that branch rather than firing it.

Item 31 — unreachable_reason(kind) separates "cannot reach it" from "its key is untrusted", on the kind and never the OS string, because this is the sanitiser. It had no test at all; it has two, and the mutation for one restores e.to_string(), which is the bug the arm exists to prevent.

Item 17 residual — error_body's buffer is Zeroizing and reserved. read_bounded_json's serde_json scratch is not fixed and cannot be from here; recorded as a limit rather than closed. I-10's test guards the helper, not the call site, and now says so in its own doc comment — driving read_bounded would put hyper's buffers inside the canary detector's window, and a test that fails for a cause we cannot fix is worse than one that states what it does not check.

Doc nits done. And you were right to narrow the role-validation claim: what shipped is a syntactic name-format rule shared by the save path and the connect path, not a check that the role exists. warpgate-admin has no dependency on warpgate-vault — verified in its Cargo.toml — and there is no lookup to call. The write-up says that now.

Item I-4/I-6 attribution: you are right, both landed in 110820c5. Noted here rather than by editing the earlier comment.

The mutation matrix, in CI and measured

.github/workflows/mutation-matrix.yml: the guards whose anchor file a PR touched on pull_request, all of them on a schedule and on dispatch. A guard with no named discriminator fails the build.

With the honest cost attached, because it changes the recommendation. A full sweep is 14h20m on an M-series laptop. GitHub-hosted runners are 4 vCPU and slower, and the job limit is six hours, so the full sweep does not fit there at all. And the per-PR saving is smaller than it sounds: --changed against four commits selected 34 of 53 guards, because client/mod.rs carries two thirds of them. You may well want only the scheduled half, or a self-hosted runner, or the whole thing sharded — that is your call and I would rather hand you the number than the recommendation.

Six defects turned up in the instrument itself along the way. It would not start at all — its own duplicate check tripped on a duplicate that had been committed. Its documented invocation ran from nowhere. It paid the whole repository's collection cost for a single guard, in three separate places. It printed nothing for hours, so three runs were killed as hung. Two runs executed side by side rewriting the same files, because the lock was written rather than checked. And the mutated gateway leaked into the next guard's baseline, which voided 28 verdicts of a preceding sweep.

53 of 53 guards discriminate, measured in one run at 02792acfc after all six were fixed, tests/mutation-matrix.json carrying partial: false and refused: null. What that does not say: that these are the right guards, or that the set is complete. Different claims, neither made here.

Not done, deliberately

Upstream logs client-controlled strings through Display at about a dozen sites in warpgate-protocol-ssh/src/server/session.rs — exec commands, environment variable names and values, subsystem names, socket paths, a login name, and two "Target {} not authorized for user {}" lines interpolating the name from the client's own login string. Same mechanism as item 22 by a shorter route: no Vault, no admin access, just an SSH client sending an environment variable. Every one verified present on origin/main, so none of it is ours. It belongs upstream as its own change rather than widening a diff you have already called large — but I did not want to decide the scope of somebody else's security fix silently, so: your call.

Your process suggestion

It sat unanswered for six days and you were right to raise it again. It is now half-built, and not in the shape you drew.

Rather than RED and GREEN subagents inside one process, the split is across two providers with hard role separation: I build and operate checks, an independent auditor specifies them and rules, and neither writes into the other's territory. Deterministic checks are run by orchestration, as you said — that is what the matrix is.

It paid for itself in a way I would not have predicted. The auditor found the dirty-binary leak above — from a running sweep's output, not from reading the code. I had looked at the same two already failing lines in my own status notes and written "look into this later". Every other defect this round came from someone reading; that one came from someone watching. It is the strongest argument for the split that I have, and it is not the argument either of us made for it.

Everything green after the merge: 63 + 26 + 4 unit tests, 109 integration tests against a real sshd.

…icate-auth

# Conflicts:
#	Cargo.lock
#	warpgate-protocol-ssh/Cargo.toml
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants